package at.ac.tuwien.dse.health.geo;

import at.ac.tuwien.dse.health.entity.*;
import at.ac.tuwien.dse.health.event.annotation.Observe;
import at.ac.tuwien.dse.health.event.bus.EventBus;
import at.ac.tuwien.dse.health.event.type.Event;
import at.ac.tuwien.dse.health.reservation.ReservationConfirmation;
import at.ac.tuwien.dse.health.reservation.ReservationRequest;
import com.google.common.base.Function;
import com.google.common.base.Predicate;
import com.google.common.collect.Ordering;
import com.google.common.primitives.Longs;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.message.StringFormattedMessage;
import org.springframework.data.mongodb.core.geo.Circle;
import org.springframework.data.mongodb.core.geo.GeoResult;
import org.springframework.data.mongodb.core.geo.GeoResults;
import org.springframework.transaction.annotation.Transactional;

import javax.inject.Inject;
import javax.persistence.EntityManager;
import javax.persistence.NoResultException;
import javax.persistence.PersistenceContext;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Map;

import static com.google.common.collect.Iterables.*;
import static com.google.common.collect.Maps.toMap;
import static org.apache.commons.collections.ComparatorUtils.nullHighComparator;

/**
 * Performs {@link SurgerySlot} reservations for {@link ReservationRequest ReservationRequests} based on geographical
 * as well as temporal parameters for optimizing operating theatre occupancy rate.
 *
 * @author Bernhard Nickel
 * @author Gregor Schauer
 * @author Dominik Strasser
 */
public class HospitalAllocator {
	private static final Logger LOGGER = LogManager.getLogger(HospitalAllocator.class);

	EventBus eventBus;
	GeoHospitalService geoHospitalService;
	@PersistenceContext
	EntityManager em;

	/**
	 * This surgery slot represents the worst available surgery slot which is that far in the future so that it never
	 * takes place. It is only intended to replace null values if necessary.
	 */
	static final SurgerySlot DEFAULT_SURGERY_SLOT = new SurgerySlot();

	static {
		DEFAULT_SURGERY_SLOT.setStartDate(new Date(Long.MAX_VALUE));
		DEFAULT_SURGERY_SLOT.setEndDate(DEFAULT_SURGERY_SLOT.getStartDate());
	}

	/**
	 * Handles incoming {@link ReservationRequest ReservationRequests} by attempting to find a {@link GeoHospital} in
	 * range of the {@link Patient} that has a {@link Department} that supports surgeries of the required type.
	 * <p/>
	 * After selecting all candidates within range, those that have the required {@link Department} are selected.
	 * Afterwards, the first {@link SurgerySlot} that is the least far in the future is reserved if possible.
	 *
	 * @param event the reservation request to process
	 */
	@Observe
	@Transactional
	public void handleReservationRequest(Event<ReservationRequest> event) {
		final ReservationRequest request = event.getValue();
		Patient patient = em.find(Patient.class, request.getPatientId());
		if (patient == null) {
			LOGGER.warn("Patient with ID %s does not exist", request.getPatientId());
			return;
		}

		// Find all GeoHospitals in range
		Circle circle = new Circle(patient.getLocation().getLatitude(), patient.getLocation().getLongitude(), request.getRadius());
		GeoResults<GeoHospital> results = geoHospitalService.findInRange(circle);

		// If there is at least one Hospital in range
		SurgerySlot slot = null;

		if (!results.getContent().isEmpty()) {
			// Select those, which support surgeries of the requested type
			Iterable<GeoResult<GeoHospital>> geoHospitals = filter(results, new DepartmentPredicate(request.getType()));

			// Load the surgery slots of the Hospitals
			Iterable<Map.Entry<GeoResult<GeoHospital>, List<SurgerySlot>>> hospitals
					= toMap(geoHospitals, new HospitalSurgerySlotsFunction()).entrySet();

			// As long as reservation request failed i.e., the slot was reserved for another surgery in the meantime
			while (slot == null && !isEmpty(hospitals)) {
				// Select the Hospitals that offer at least one SurgerySlot
				hospitals = filter(hospitals, new Predicate<Map.Entry<GeoResult<GeoHospital>, List<SurgerySlot>>>() {
					@Override
					public boolean apply(Map.Entry<GeoResult<GeoHospital>, List<SurgerySlot>> input) {
						return !isEmpty(input.getValue());
					}
				});
				if (isEmpty(hospitals)) {
					continue;
				}

				// Sort them by the first available date
				Map.Entry<GeoResult<GeoHospital>, List<SurgerySlot>> available = SURGERY_SLOT_ORDERING.min(hospitals);

				// Get the first available slot
				slot = getFirst(available.getValue(), null);

				try {
					// Attempt to reserve the selected surgery slot
					Department department = (Department) em.createNamedQuery(Department.GET_DEPARTMENT_BY_NAME)
							.setParameter("name", request.getType()).getSingleResult();
					slot.setDepartment(department);
					slot.setDoctor(em.find(Doctor.class, request.getDoctorId()));
					slot.setPatient(em.find(Patient.class, request.getPatientId()));
					em.merge(slot);
				} catch (Exception e) {
					// Remove the selected slot from the available ones
					if (!isEmpty(available.getValue())) {
						available.getValue().remove(0);
					}
					slot = null;
					LOGGER.warn("Cannot reserve surgery slot for " + request, e);
				}
			}
		}

		// Request a reservation for the SurgerySlot found or notify the requestor that there is no slot available
		eventBus.fire(new ReservationConfirmation(request, slot != null ? slot.getId() : null));
	}

	/**
	 * Compares two {@link SurgerySlot SurgerySlot} based on their starting time.
	 * <p/>
	 * It returns a negative value if {@code o1} is earlier than {@code o2}; a positive value if {@code o1} is later
	 * than {@code o2}; or zero if they start at the same time.
	 * <p/>
	 * Note that this implementation is null-safe. Thus if a {@link SurgerySlot} is {@code null}, it is treated as
	 * being later than any non-null {@link SurgerySlot}.
	 */
	@SuppressWarnings("unchecked")
	static final Comparator<SurgerySlot> SURGERY_SLOT_COMPARATOR = nullHighComparator(new Comparator<SurgerySlot>() {
		@Override
		public int compare(SurgerySlot o1, SurgerySlot o2) {
			return Longs.compare(o1.getStartDate().getTime(), o2.getStartDate().getTime());
		}
	});

	/**
	 * Sorts ({@link GeoHospital}, {@link SurgerySlot}) tuple by their first available {@link SurgerySlot}.
	 */
	public static final Ordering<Map.Entry<GeoResult<GeoHospital>, List<SurgerySlot>>> SURGERY_SLOT_ORDERING
			= Ordering.from(new Comparator<Map.Entry<GeoResult<GeoHospital>, List<SurgerySlot>>>() {
		@Override
		public int compare(Map.Entry<GeoResult<GeoHospital>, List<SurgerySlot>> o1,
						   Map.Entry<GeoResult<GeoHospital>, List<SurgerySlot>> o2) {
			SurgerySlot first = getFirst(o1.getValue(), DEFAULT_SURGERY_SLOT);
			SurgerySlot second = getFirst(o2.getValue(), DEFAULT_SURGERY_SLOT);
			return SURGERY_SLOT_COMPARATOR.compare(first, second);
		}
	});

	/**
	 * Filters all {@link GeoHospital GeoHospitals} that support a certain {@link Department}.
	 */
	protected class DepartmentPredicate implements Predicate<GeoResult<GeoHospital>> {
		private String name;

		protected DepartmentPredicate(String name) {
			this.name = name;
		}

		@Override
		@Transactional
		public boolean apply(GeoResult<GeoHospital> input) {
			try {
				Hospital hospital = (Hospital) em.createNamedQuery(Hospital.WITH_DEPARTMENT_BY_NAME)
						.setParameter("name", input.getContent().getName()).getSingleResult();
				return any(hospital.getDepartments(), new Predicate<Department>() {
					@Override
					public boolean apply(Department input) {
						return input.getName().equals(name);
					}
				});
			} catch (NoResultException e) {
				LOGGER.fatal(new StringFormattedMessage("Inconsistency in geo database detected: Hospital '%s' with ID '%d' does not exist",
						input.getContent().getName(), input.getContent().getId(), e));
				return false;
			}
		}
	}

	/**
	 * Loads the available {@link SurgerySlot SurgerySlot} of a certain {@link GeoHospital}.
	 */
	private class HospitalSurgerySlotsFunction implements Function<GeoResult<GeoHospital>, List<SurgerySlot>> {
		@Override
		@SuppressWarnings("unchecked")
		@Transactional
		public List<SurgerySlot> apply(GeoResult<GeoHospital> input) {
			return em.createNamedQuery(SurgerySlot.GET_EMPTY_SLOTS).setParameter("filterObject", input.getContent().getName()).getResultList();
		}
	}

	@Inject
	public void setEventBus(EventBus eventBus) {
		this.eventBus = eventBus;
	}

	@Inject
	public void setGeoHospitalService(GeoHospitalService geoHospitalService) {
		this.geoHospitalService = geoHospitalService;
	}
}
